不为失败找理由,要为成功找方法。

【搜索客社区日报】第2319期 (2026-10-27)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 220 次浏览 • 14 小时前 • 来自相关话题

1. NVIDIA RTX GPU 加持:Elastic 拟将 Jina 模型推理速度飙升 8 倍  
https://www.elastic.co/blog/ji ... -gpus

2. 驰骋赛道:Elastic 如何助力奥迪 Revolut F1® 车队从海量工程软件中洞察先机  
https://www.elastic.co/blog/el ... rship

3. AI 到底能不能写论文?盘点它的能与不能(需要梯子)  
https://dev.to/jillianj/can-ai-write-papers-3g22

4. Elastic Security 携手 Google 分布式云:填补隔离网络环境下的速度鸿沟  
https://www.elastic.co/blog/el ... cloud

5. 四步走,教你烹饪出一套稳健的生成式 AI 数据战略  
https://www.elastic.co/blog/4- ... ategy


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2315期 (2026-10-26)

社区日报 • Muses 发表了文章 • 0 个评论 • 335 次浏览 • 1 天前 • 来自相关话题

1. 我们如何通过70多个拉取请求将OpenTelemetry Java agent迁移到invokedynamic
https://blog.csdn.net/UbuntuTo ... 31931

2. 从56页扫描文档到带引用的答案只需5分钟:为搜索无法读取的文档构建RAG流水线(需要梯子)
https://www.elastic.co/search- ... a-ocr

3. 从 LOOP 到图探索 —— 算法模型的 AutoResearch 自进化系统架构演进之路
https://tech.meituan.com/2026/ ... .html

4. NVIDIA Nemotron嵌入模型在OpenSearch智能体搜索中的应用(需要梯子)
https://opensearch.org/blog/nv ... arch/

5. Easysearch 查询性能优化:为什么Filter缓存能让搜索飞起来?
https://easysearch.cn/knowledg ... ster/

编辑:Muse
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2314期 (2026-10-20)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 1956 次浏览 • 2026-10-20 08:26 • 来自相关话题



1. 搜索与自动补全系统设计全解:倒排索引、Trie、排序与扩展性(需要梯子)
https://medium.com/%40mangesh. ... 282f7
2. 用大白话搜足球赛事:自然语言搜索实战
https://python.plainenglish.io ... 2f794
3. 数据库索引入门到精通:从 B 树到倒排索引(需要梯子)
https://medium.com/%40mehmetaz ... 2c24c
4. 查询很简单,响应却很慢:深入排查 OpenSearch 搜索延迟(需要梯子)
https://medium.com/%40babayevq ... 46292
5. 让数据跨越边界:警务领域面临的下一个挑战
https://www.elastic.co/blog/ma ... icing
编辑:斯蒂文
更多资讯:http://news.searchkit.cn
 

【搜索客社区日报】第2313期 (2026-10-19)

社区日报 • Muses 发表了文章 • 0 个评论 • 2040 次浏览 • 2026-10-19 15:17 • 来自相关话题

1. 超越Top-K:为OpenSearch智能体AI构建搜索置信度护栏(需要梯子)
https://opensearch.org/blog/be ... arch/

2. Kubernetes 节点压力:在首次驱逐容器组前7分钟捕捉到内存下降
https://blog.csdn.net/UbuntuTo ... 75479

3. 一条 ES|QL 查询,涵盖365天的审计日志:在热数据和可搜索快照上执行相同的搜索
https://blog.csdn.net/UbuntuTo ... 70602

4. 使用新数据源插件在Perses中探索OpenSearch日志(需要梯子)
https://opensearch.org/blog/ex ... ugin/

5. 排序 + 搜索一起用,Easysearch 性能会怎样变化
https://easysearch.cn/knowledg ... pact/

编辑:Muse
更多资讯:http://news.searchkit.cn

Easysearch 2.5.0 发布:升级 JDK 25,写入吞吐提升约 20%

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 2039 次浏览 • 2026-10-19 14:39 • 来自相关话题

![release](https://infinilabs.cn/img/blog/release/banner.png)

INFINI Easysearch 2.5.0 正式发布。本次更新支持 JDK 25 并将其作为 bundle 安装包的默认 JDK,并修复部署、启动和管理界面中的多项问题。

在约 1,140 万条文档的 geonames 基准测试中,Easysearch 2.5.0 搭配 GraalVM 25,相比 Easysearch 2.4.0 搭配 GraalVM 21,批量写入吞吐提升约 20%。match_all、term 和 match_phrase 查询的 P99 延迟降低 30%~52%。

写入吞吐提升约 20%


测试套件使用[geonames](https://github.com/elastic/ral ... onames),它是 esrally 提供的基准测试,使用包含地名、人口、国家代码和经纬度等字段的数据,覆盖批量写入及多种查询。

在相同集群配置下,Easysearch 2.5.0 搭配 GraalVM 25.0.4,批量写入吞吐从约 12.9 万文档/秒提升到 15.5 万文档/秒。

| Easysearch 与内置 JDK | 写入吞吐(文档/秒) | 吞吐提升 |
| --------------------------------- | ------------------: | -------: |
| Easysearch 2.4.0 + GraalVM 21.0.9 | 129,281 | 基准 |
| Easysearch 2.5.0 + GraalVM 25.0.4 | 155,124 | +20.0% |

写入过程中,偶发慢请求的耗时也有所降低:各轮最慢 bulk 请求服务时间的中位数,从 2,095.5 ms 降至 230.1 ms,降低约 89%。

详细的测试环境说明见文末。

常见查询的 P99 延迟降低 30%~52%


查询方面,得益于 JDK25 对 GC 等方面的优化,本轮测试的收益主要体现在长尾,也就是少数耗时较长的请求。

下面选取三种常见查询:match_all 匹配全部文档,term 按国家代码精确查询,match_phrase 按地名进行短语查询。查询定义见 [geonames 测试操作](https://github.com/elastic/ral ... t.json)。

| 查询 | Easysearch 2.4.0 + GraalVM 21 的 P99(ms) | Easysearch 2.5.0 + GraalVM 25 的 P99(ms) | 延迟降低 |
| ----------------------- | -----------------------------------------: | -----------------------------------------: | -------: |
| match_all 全量匹配 | 16.1 | 7.8 | 51.6% |
| term 精确查询 | 12.5 | 7.7 | 38.4% |
| match_phrase 短语查询 | 12.0 | 8.4 | 30.0% |

详细的测试环境说明见文末。

Bundle 包默认内置 JDK 25


Bundle 包是包含 JDK 的安装包。Easysearch 2.5.0 的 Bundle 包默认内置 JDK 25。使用不含 JDK 的安装包时,同样推荐选择 JDK 25。

部署与管理体验修复


本版本还修复了以下问题:

  • 用户名或密码包含中文时,UI 登录失败。
  • 概览页在非 external 模式下错误显示“获取集群准入凭据”入口,现在仅在 Agent 托管的 external 模式显示。
  • 审计日志列表为空时,导出提示显示翻译 key。
  • 启动失败后进程持续运行,现在以非零状态退出,便于部署工具识别失败。
  • Windows 下部分 HTTP 客户端初始化失败,以及关闭 HTTP TLS 时节点加入接口返回 500。

    此外,面向 LogPilot 集成新增可选的 ingest-pattern-tagger 插件,用于在日志写入时识别模式并打标,支持变量抽取,并提供配套的 pattern_restore 搜索管道处理器。使用方式见[Pattern 打标处理器文档](https://docs.infinilabs.com/ea ... agger/)。

    完整变更见 [Easysearch 2.5.0 发布日志](https://docs.infinilabs.com/ea ... -10-16)。

    升级提示


    本次升级包含 JVM 配置的不兼容变更。如果保留旧的 config/jvm.options 或使用自定义配置目录,请以 2.5.0 发行包的配置为基础进行迁移,不要直接用旧文件覆盖新配置。如若继续使用 JDK 21,也必须同步这些变更,否则节点无法启动。

    升级前请备份数据,并在测试环境验证现有插件和业务请求。JDK 选择、JVM 参数替换及配置覆盖检查,请按[升级到 2.5.0:JDK 与 JVM 安全配置](https://docs.infinilabs.com/ea ... d%25ae)操作。

    性能测试环境说明


    测试对比 Easysearch 2.4.0(内置 GraalVM 21.0.9)与 Easysearch 2.5.0(内置 GraalVM 25.0.4)。

    测试集群使用 3 台虚拟机,每台配置 32 vCPU、32 GiB 内存,部署 3 个 Easysearch 节点。每节点固定 16 GiB 堆,使用默认 G1 垃圾回收器,开启 HTTPS 和安全功能。

    数据集为 geonames,共 11,396,503 条文档。索引使用 3 个主分片、0 副本,写入使用 8 个 bulk 客户端。每轮清空测试数据并重新启动 JVM;查询前将每个分片合并为 1 个段,并检查堆大小和段数。

    获取新版本


    Easysearch 2.5.0 安装包可从[下载页面](https://infinilabs.cn/download/) 获取。新部署推荐选择包含 JDK 25 的 Bundle 包,安装步骤见[快速开始](https://docs.infinilabs.com/ea ... start/)。

    ---

    关于 Easysearch


    ![](https://infinilabs.cn/img/blog ... v2.png)

    INFINI Easysearch 是一款分布式搜索引擎,支持结构化和非结构化的数据检索、全文检索、向量检索、空间地理位置信息检索、组合查询、多语种支持、语义分析和聚合分析等多种功能。

    Easysearch 基于 Lucene 构建,紧跟 Lucene 最新版本持续迭代更新,采用商用友好协议,企业可自由部署、二次开发和商业化,无协议合规风险。

    Easysearch 致力于为企业提供轻量、安全、自主可控的搜索平台,不断完善产品能力,满足更多企业级需求。

    官网:<https://easysearch.cn>;

    ![](https://infinilabs.cn/img/blog ... ty.png)

【搜索客社区日报】第2312期 (2026-10-16)

社区日报 • Fred2000 发表了文章 • 0 个评论 • 2789 次浏览 • 2026-10-16 14:56 • 来自相关话题

1、向量检索 vs 全文检索:Easysearch 如何理解意思而非字面
https://mp.weixin.qq.com/s/-_LPYbTh8z9JGsr3vYUWFA

2、Elasticsearch 聚合字段用 text 会 OOM?
https://mp.weixin.qq.com/s/oqBOHI7U3ArgaNUBFggflw

3、ES 集群巡检背后:13 项指标巡检设计
https://mp.weixin.qq.com/s/1cxksyvJcBhXcSn9MAdaow

4、知识不是文件,也不是向量 | KDC 系列 02
https://my.oschina.net/vivotech/blog/19765666

5、混合检索:向量 + BM25 + RRF + Rerank
https://mp.weixin.qq.com/s/g6lVUS__LeZjSdKOINyR_A

编辑:Fred
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2311期 (2026-10-15)

社区日报 • Se7en 发表了文章 • 0 个评论 • 3028 次浏览 • 2026-10-15 18:09 • 来自相关话题

1.在 Anthropic 重新设计源代码控制
https://www.anthropic.com/engi ... ntrol
2.拆开 Grok Bot,看看一个 Personal Agent 是怎么工作的
https://mp.weixin.qq.com/s/NTn ... e%3D1
3.SI的下一个拐点,vLLM-Omni 如何让模型边听边说
https://www.bilibili.com/video/BV1zRpg6REeh

编辑:Se7en
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2310期 (2026-10-14)

社区日报 • kin122 发表了文章 • 0 个评论 • 3162 次浏览 • 2026-10-15 11:01 • 来自相关话题

1. 从 19TB 降至 9TB:Elasticsearch 合成数据源使我们的存储需求减少了一半(搭梯)
https://medium.com/%40nitishgo ... 83663

2. 我们是如何驯服 Elasticsearch 的,通过合并 CPU 资源,将每秒可写入的文档数量提升到了 500 万份(搭梯)
https://medium.com/%40nitishgo ... 8e383

3. 隆重推出 AlertZero:让你的告警队列实现 “收件箱清零” 式管理
https://elasticstack.blog.csdn ... 65443



编辑:kin122    
更多资讯:http://news.searchkit.cn

生态共建|Easysearch 与熙瑾会悟完成兼容性互认证

资讯动态 • INFINI Labs 小助手 发表了文章 • 0 个评论 • 3361 次浏览 • 2026-10-14 16:39 • 来自相关话题

近日,极限数据(北京)科技有限公司(简称:[极限科技](https://infinilabs.cn/))与上海熙瑾信息技术有限公司(简称:[上海熙瑾](https://www.icccall.com/))正式宣布,双方已完成产品兼容性互认证测试。经严格联合测试验证,上海熙瑾旗下的“熙瑾会悟”AI会议产品与极限科技自主研发的 [Easysearch](https://easysearch.cn/) 分布式搜索引擎在功能适配、运行性能及系统兼容性上表现优异,整体运行稳定,可充分满足政企用户的关键性业务应用需求。

![](https://infinilabs.cn/img/blog ... /1.png)

随着政企数字化转型的深入,对于会议场景的智能化与数据安全性提出了更高要求。此次互认证的两款产品均在各自领域拥有深厚的技术积淀。

熙瑾会悟是上海熙瑾信息技术有限公司专为政企打造的高保密AI会议秘书。该产品主打“单机离线部署,数据本地化”,从源头上保障了会议数据的安全。在功能上,熙瑾会悟支持语音智控,用户只需下达指令,即可实现自动实时转写、声纹识别,并智能生成会议纪要。其以轻量化的成本,重塑了安全会议的全新体验,特别适合对保密性有极高要求的党政机关及大型企业。

Easysearch 则是极限科技自主研发的分布式搜索引擎。作为一款核心定位为“兼容 Elasticsearch、自主可控、轻量高效”的国产搜索引警,它具备强大的非结构化数据检索、全文检索及向量检索能力。同时,Easysearch 还支持地理空间检索、多语种处理、聚合分析、AI集成以及多模态混合检索等企业级高阶需求,能够为海量数据提供高效、稳定的底层搜索支撑。

此次兼容性互认证测试,是双方技术团队深度合作的成果。测试结果表明,熙瑾会悟与 Easysearch 能够完美协同工作。Easysearch 强大的检索与分析能力,能够为熙瑾会悟在处理海量会议录音、转写文本及会议纪要时提供坚实的底层技术支撑,确保数据的快速索引与精准调取。

双方产品在功能、性能及兼容性方面的良好表现,不仅验证了技术架构的先进性,更为政企客户提供了更加安全、高效、智能的会议解决方案。双方表示,未来将继续深化合作,共同探索 AI 与搜索技术在垂直场景中的更多应用可能,助力政企客户实现数字化与信创化的高质量发展。

【搜索客社区日报】第2309期 (2026-10-13)

社区日报 • God_lockin 发表了文章 • 0 个评论 • 3804 次浏览 • 2026-10-13 08:16 • 来自相关话题

1. Elastic Cloud Serverless 再下一城:新增 Azure 区域支持  
https://www.elastic.co/blog/el ... -iowa

2. 在印度打造生产级 AI:从落地到规模化  
https://www.elastic.co/blog/mu ... -2026

3. 当 AI 智能体蜂拥而至,银行还跟得上吗?  
https://www.elastic.co/blog/ba ... ility

4. 从被动救火到韧性十足:Elastic 可观测性 2026 总体经济影响报告解读  
https://www.elastic.co/blog/to ... -2026

5. Elastic Cloud 静态加密:企业安全不容忽视的战略刚需  
https://www.elastic.co/blog/en ... urity


编辑:斯蒂文  
更多资讯:http://news.searchkit.cn

〖搜索客社区日报〗第2308期 (2026-10-12)

社区日报 • Muses 发表了文章 • 0 个评论 • 3946 次浏览 • 2026-10-12 14:31 • 来自相关话题

1. 使用 Elastic Stack 丰富邮政地址信息 —— 第二部分
https://blog.csdn.net/UbuntuTo ... 74781

2. JEV-27B-VL 开源:多模态决策模型,Jev Decision Index 0.3 视觉榜单第一
https://modelscope.csdn.net/6a ... .html

3. 我们如何构建上下文引擎将智能体Token消耗降低71%(需要梯子)
https://www.elastic.co/search- ... gents

4. 使用 Lucene 搜索你的 Bean — 索引
https://blog.csdn.net/UbuntuTo ... 72107

5. INFINI Easysearch 向量搜索实战(二)
https://infinilabs.cn/blog/202 ... ch-2/

编辑:Muse
更多资讯:http://news.searchkit.cn
 

【搜索客社区日报】第2307期 (2026-10-09)

社区日报 • Fred2000 发表了文章 • 0 个评论 • 4859 次浏览 • 2026-10-09 14:26 • 来自相关话题

1、CQ-SID:LLM生成式检索在手猫搜索召回中的探索与应用
https://mp.weixin.qq.com/s/USAPkWw031i8d2D37jBqtg

2、用 Jev 做搜索重排:基准与实现
https://mp.weixin.qq.com/s/tnRBIVW0dB9UsDjOc-z1gQ

3、Easysearch 原生集成国密算法:打造全链路自主可控搜索底座(二)
https://infinilabs.cn/blog/202 ... pto2/

4、Doris 直查 Paimon 索引:快手湖上向量检索的共建与落地
https://my.oschina.net/selectdb/blog/19757897

5、Elasticsearch 中使用 NVIDIA cuVS GPU 加速构建向量索引:1.38 亿个向量不到 10 分钟
https://mp.weixin.qq.com/s/gEuiL5Nf5P-jaS2TNr-9sA

编辑:Fred
更多资讯:http://news.searchkit.cn

【搜索客社区日报】第2306期 (2026-09-30)

社区日报 • kin122 发表了文章 • 0 个评论 • 8878 次浏览 • 2026-09-30 15:11 • 来自相关话题

1. 4GB 内存扛住 10 亿向量检索!这款开源向量数据库杀疯了!
https://mp.weixin.qq.com/s/kEM2Mx4p0x7Qobz1GjFKVQ

2. 使用 Lucene 搜索你的 beans — Suggest
https://blog.csdn.net/UbuntuTo ... 90208

3.14 个 alerts,1 个 incident:使用 Elasticsearch 中的 ES|QL 衡量 alerting rule 噪声
https://blog.csdn.net/UbuntuTo ... 87758

4. 使用 Lucene 搜索你的 Bean —— Highlighting
https://blog.csdn.net/UbuntuTo ... 42502

5. Agent负载中的动态 JSON 处理:Apache Doris 与 ClickHouse、Elasticsearch 以及 OpenSearch 的对比(搭梯)
https://medium.com/%40VeloDB_p ... 0efab

6. RAG、Vectors、Elasticsearch 以及Graphs。What, When, How, and Where.
https://michalmolka.medium.com ... aaaa5


编辑:kin122    
更多资讯:http://news.searchkit.cn

Easysearch 布尔查询子句重排序(四)|Block-Max、实战与验证

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 9288 次浏览 • 2026-09-29 19:06 • 来自相关话题

![](https://infinilabs.cn/img/blog ... er.png)

Block-Max WAND 块级剪枝、混合查询端到端追踪、Profile API 亲手验证

_[INFINI Easysearch](https://easysearch.cn/) 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。_

---

引言:从标准 WAND 到块级剪枝


前三篇我们走完了 Easysearch 布尔查询优化的前半程:[第一篇](https://infinilabs.cn/blog/202 ... art-1/)讲 rewrite 11 条规则,[第二篇](https://infinilabs.cn/blog/202 ... art-2/)讲合取的 cost 排序,[第三篇](https://infinilabs.cn/blog/202 ... art-3/)讲析取的 WAND 算法——用 maxScore 上界 + 三堆调度,把"上界够不到及格线的文档"在打分前就剪掉。

但标准 WAND 留了个明显短板:每个子句的上界是全局的——"这个词在全索引最多能打多少分"。游标走到低分文档区,上界仍按全局最高估,剪枝不够激进。本文就围绕这块补全,并把前面所有内容串起来做实战和验证:

  • 一 Block-Max WAND——把全局上界换成"按块分段"的上界,整块低分文档一次跳过
  • 二 实战——MUST + SHOULD 混合查询的端到端追踪,看前几篇内容怎么协作
  • 三 Profile API——用对比实验亲手验证 WAND 生效,避开常见误读
  • 四 开发者启示
  • 五 全系列总结

    ---

    一、Block-Max WAND 的增强


    1.1 一个类比:为什么全局上界会"过乐观"


    想象一场考试,每个学生最多能考 100 分。现在老师想知道"有没有人超过 90 分"。

  • 标准 WAND 的做法:每个学生头顶都贴着"100"(全局上限)。老师得把所有人都叫过来核对,因为光看标签谁都"可能"过 90。
  • 聪明的做法:把学生按班级分组,每个班只记一个"本班最高分"。如果某班最高才 60,整班直接跳过,不用一个个核对。

    Block-Max WAND 就是这个"按班级分组"的做法。倒排链在物理存储上本来就是分块的——Lucene 把每 128 篇文档压缩成一个 block([ForUtil.BLOCK_SIZE = 128](https://github.com/apache/luce ... %23L32)),写入时顺手记下"这个 block 内最高能打多少分"。这个分段级上界比全局 maxScore 紧得多——因为它只看本 block 的数据,不会被远处的高分文档带偏。

    一句话区分:标准 WAND 的上界是"这个词在全索引最多能打多少分";Block-Max 的上界是"这个词在当前这个 block最多能打多少分"。后者永远 ≤ 前者,所以更容易触发剪枝。

    1.2 分段上界 vs 全局上界:一张图看懂


    <br /> 标准 WAND Block-Max WAND<br /> <br /> ┌──────────────┐ ┌────────────────────────────────┐<br /> docID │全局上界=9 │ │块0 doc 0-127 max=9 高分段 │<br /> ↑ │不管游标到哪 │ ├────────────────────────────────┤<br /> │上界都是=9 │ │块1 doc 128-255 max=5 中分段 │<br /> │ │ ├────────────────────────────────┤<br /> │ │ │块2 doc 256-383 max=2 低分段★ │<br /> └──────────────┘ └────────────────────────────────┘<br /> <br /> 及格线 = 8,游标已进入 Block 2(低分段):<br /> <br /> 标准 WAND:上界估 9(全局),9 ≥ 8 → 不剪,逐文档查<br /> Block-Max:上界估 2(本块), 2 < 8 → 跳过整个 block<br />

    两种策略的对比一目了然——同一个及格线 8、同一个 Block 2:标准 WAND 还用全局 max=9 估计,以为"可能过线"就逐文档查(保守);Block-Max 用本块 max=2 估计,一眼看出整块无望直接跳过(激进),一次比较换掉 128 次 advance。

    1.3 源码层面:三个动作怎么串起来


    Block-Max 在标准 WAND 之外多调了两个底层方法,理解它们的名字就抓住了主线。这两个方法由每个子句的 Scorer 实现(如 TermWeight 内部的 ImpactsScorer),WANDScorer 通过自己的私有协调方法 [WANDScorer.updateMaxScores()](https://github.com/apache/luce ... r.java) 把它们串进三堆调度:

  • advanceShallow(target) —— "浅推进"。只跳到 target 所在的 block 边界,不逐文档解码。开销远低于真正的 advance(target),相当于"瞄一眼那个班的最高分标签"。
  • getMaxScore(upTo) —— 返回"当前 block 内"的最高分上界。这是上面图里 max=2 那个数的来源,比全局 maxScore 紧得多。
  • updateMaxScores() —— 协调者:游标进入新 block 时,用上述两个方法重算每个子句的分段 maxScore,替换掉之前用的全局值,重新累加上界。发现 tail 上界已经够线,就把高分 essential 子句推进到 head 去实际查。

    三者配合后,[第三篇 §3.7](https://infinilabs.cn/blog/202 ... 2337-数字示例一次完整的剪枝流程) 那张状态图右侧的 tailMax 数值会逐 block 变小——因为用的是分段上界而不是全局上界,更容易跌破及格线。

    1.4 最底层:ImpactsDISI 的块级跳过


    分段信息从哪来?写入索引时就备好了。Lucene 的 Impacts 数据结构在 flush 阶段为每个 block 预计算"本块内各 (freq, norm) 组合对应的最高分",查询时由 MaxScoreCache([MaxScoreCache.java:72-78](https://github.com/apache/luce ... 72-L78))读出来。

    真正执行跳过的是 [ImpactsDISI](https://github.com/apache/luce ... I.java)。它内嵌一个 minCompetitiveScore,每次游标要 advance(target) 时先做一道判断([ImpactsDISI.java:68-100](https://github.com/apache/luce ... 8-L100)):

    java<br /> upTo = maxScoreCache.advanceShallow(target); // 找到 target 所在 block 的末尾 docID<br /> maxScore = maxScoreCache.getMaxScoreForLevelZero(); // 本块分段上界<br /> while (maxScore < minCompetitiveScore) { // 本块最高分都够不到及格线<br /> target = maxScoreCache.getSkipUpTo(...) + 1; // 直接跳到下一个可能有戏的 block<br /> upTo = maxScoreCache.advanceShallow(target);<br /> maxScore = maxScoreCache.getMaxScoreForLevelZero();<br /> }<br /> in.advance(target); // 只在"有戏"的 block 内才真正推进<br />

    效果:跳过的单位从"单个文档"升级到"整个 block(128 篇)"。一次 advanceShallow 比较,省掉最多 128 次 advance。

    名词对照:advanceShallow / getMaxScore / Impacts / ImpactsDISI / MaxScoreCache 是源码里的正式名字;"分段上界""块级跳过""block"是本文用的通俗说法,指的是同一回事。

    1.5 剪枝的反馈闭环:从"猜"到"越来越准"


    把第一章的几层串起来看,剪枝是一个自适应加速的闭环:

    <br /> Collector 收满 K 个结果<br /> │<br /> │ setMinCompetitiveScore(kthBestScore)<br /> ▼<br /> WANDScorer 收到及格线(抬高)<br /> │<br /> │ ① matches():用 lead+tail 的 maxScore 上界跳过低分候选<br /> │ ② updateMaxScores():用分段上界替换全局上界,上界变紧<br /> ▼<br /> ImpactsDISI 收到分段级 minCompetitiveScore<br /> │<br /> │ 整个 block 最高分都 < 及格线 → 跳过 128 篇<br /> ▼<br /> 迭代更快 → 更快碰到高分文档 → Collector 收到更高分<br /> │<br /> │ setMinCompetitiveScore(...) 再次抬高<br /> ▼<br /> (循环)及格线越抬越高,剪枝越来越激进<br />

    这个闭环的关键在于双向反馈:Collector 把及格线往下传给 WANDScorer(决定哪些文档跳过),WANDScorer 再往下传给 ImpactsDISI(决定哪些 block 跳过)。一开始及格线低、剪枝保守(还没见过高分文档,不敢贸然跳);随着高分结果不断收集,线越抬越高,剪枝越来越激进——这就是 §三里 set_min_competitive_score_count 持续增长的来源。

    ---

    二、实战:MUST + SHOULD 混合查询的端到端追踪


    结合前面几篇内容,用一个混合查询走完整路径:

    json<br /> GET /products/_search<br /> {<br /> "query": {<br /> "bool": {<br /> "must": [<br /> { "term": { "status": "published" } },<br /> { "term": { "category": "ai" } }<br /> ],<br /> "should": [<br /> { "term": { "author": "sam" } },<br /> { "term": { "tag": "featured" } }<br /> ],<br /> "minimum_should_match": 1<br /> }<br /> }<br /> }<br />

    2.1 一张图看清 Scorer 嵌套结构


    这个查询最终会被组装成一棵 Scorer 树。先看树的样子,再逐层解释:

    <br /> BooleanScorer(顶层,协调 MUST 与 SHOULD 的关系)<br /> │<br /> ┌───────────────┴────────────────┐<br /> │ │<br /> MUST 侧(合取) SHOULD 侧(析取,size=10 → TOP_SCORES)<br /> │ │<br /> ConjunctionScorer WANDScorer<br /> (按 cost 排序,最稀疏领头) (按 maxScore 调度,剪枝省打分)<br /> │ │<br /> ┌───────┴────────┐ ┌───────┴────────┐<br /> │ │ │ │<br /> category:ai status:published author:sam tag:featured<br /> (cost≈1万) (cost≈100万) (maxScore 高) (maxScore 低)<br /> ↑ lead1 ↑ lead2 └── tail 堆按 maxScore 排<br /> (最稀疏,领头跳) (高分优先推进查实)<br />

    这张树图把前面几篇内容串到了一起——每个 Scorer 的选型都有明确理由:

  • MUST 侧(合取):两个条件都要满足,用 ConjunctionScorer([第二篇](https://infinilabs.cn/blog/202 ... art-2/))。按 cost 升序排列,让 category:ai(只命中 1 万篇,最稀疏)当 lead1 领头跳,status:published(命中 100 万篇)当 lead2 跟进。lead1 跳得快,整个 MUST 侧就快。
  • SHOULD 侧(析取):两个条件满足一个就行,且查询是 Top-10(size=10),走 WANDScorer([第三篇](https://infinilabs.cn/blog/202 ... art-3/))。两个 SHOULD 子句按 maxScore 进 tail 堆,高分优先推进。
  • 顶层 BooleanScorer:把 MUST 的命中文档交给 SHOULD 侧打分。MUST 过滤掉绝大部分文档后,WAND 只需对剩下的少数文档做上界判断,二者职责互补。

    2.2 执行追踪:一篇文档怎么走过这棵树


    假设 MUST 侧的 lead1(category:ai)跳到 doc=X,整个追踪如下:

    <br /> ① MUST 侧 ConjunctionScorer:<br /> lead1 (category:ai) advance 到 doc=X<br /> → lead2 (status:published) 确认 doc=X 也在 → MUST 命中 ✓<br /> <br /> ② 顶层 BooleanScorer 把 doc=X 交给 SHOULD 侧打分<br /> <br /> ③ SHOULD 侧 WANDScorer:<br /> 把 author:sam、tag:featured 的游标推进到 doc=X<br /> → 上界判断(leadMax + tailMax vs 及格线)<br /> → 通过 → score() 算实际分<br /> <br /> ④ Collector 收集 doc=X 的分数,必要时抬高 minCompetitiveScore<br /> → 反馈回 WANDScorer 和 ImpactsDISI(第一章所说的反馈闭环)<br />

    2.3 几篇内容怎么协作


    回看整个过程,几篇内容各管一段,缺一不可:

  • [第一篇] rewrite 保证了查询结构简洁(去重、提升、展平)——这一步决定 Scorer 树的形态。
  • [第二篇] cost 排序 让 MUST 侧高效迭代(最稀疏的 category:ai 领头)——决定合取侧的跳过效率。
  • [第三篇] WAND 调度 让 SHOULD 侧高效剪枝(高分优先 + 动态上界)——决定析取侧的打分开销。
  • 本文 Block-Max 让 SHOULD 侧的上界更紧、剪枝更激进——决定析取侧的块级跳过力度。
  • 顶层合取 把两者组合:MUST 先把文档数砍到很小,WAND 再在这个小集合里挑 Top-K,各自发挥所长。

    ---

    三、动手验证:Profile API 观察 WAND 生效


    用对比实验验证 WAND 的反馈闭环:track_total_hits: false 切入 TOP_SCORES 模式(WAND 生效),track_total_hits: true 强制 COMPLETE 模式(WAND 剪枝关闭),对比 profile 的 breakdown。

    breakdown 字段含义


    profile 中每个查询节点的 breakdown 是一个 Map。每个操作有两条记录:不带 _count 后缀的是纳秒耗时,带 _count 后缀的是调用次数。与 WAND/Block-Max 最相关的几个:

    | 字段 | 含义 | 与 WAND/Block-Max 的关系 |
    | --------------------------------------------------------------- | --------------------------------- | ------------------------------- |
    | match / match_count | TwoPhaseIterator matches() 验证 | WAND 的上界判断在此发生 |
    | score / score_count | score() 实际打分 | 仅对通过 matches() 的命中调用 |
    | shallow_advance / shallow_advance_count | advanceShallow() 块级定位 | Block-Max 独有:块级跳过 |
    | compute_max_score / compute_max_score_count | getMaxScore() 计算分段上界 | Block-Max 独有 |
    | set_min_competitive_score / set_min_competitive_score_count | Collector 回传最低分阈值 | WAND 反馈闭环的直接证据 |

    构造一个能体现剪枝的数据集


    WAND 的文档级剪枝要体现为 score_count 下降,需要"少量高分文档 + 海量低分文档"的分布:高分文档先把 Top-K 的及格线抬到高位,低分文档的上界够不到线,于是被整批跳过。为此建一个专门的索引 wand_msm:

  • 100 篇 body = "rare common" —— 同时命中 rare(高 idf)和 common,分数 ≈ 5.30
  • 20000 篇 body = "common extra" —— 命中 common + extra,两个都是高频低 idf 词,分数 ≈ 0.01

    为什么加 minimum_should_match: 2?这是触发 WANDScorer 的关键。Lucene 的 [Boolean2ScorerSupplier.opt()](https://github.com/apache/luce ... 2-L263) 在 minShouldMatch > 1 时返回 WANDScorer;而纯析取(无 minimum_should_match 或 ≤1)走的是另一条 MaxScoreBulkScorer 路径([BooleanWeight.java:224](https://github.com/apache/luce ... 23L224))。两者同属 Block-Max 动态剪枝家族,但要直接观察 WANDScorer + Block-Max 的剪枝,用 minimum_should_match: 2 最干净。

    json<br /> GET /wand_msm/_search<br /> {<br /> "profile": true,<br /> "track_total_hits": false,<br /> "size": 10,<br /> "query": {<br /> "bool": {<br /> "should": [<br /> { "term": { "body": "rare" }},<br /> { "term": { "body": "common" }},<br /> { "term": { "body": "extra" }}<br /> ],<br /> "minimum_should_match": 2<br /> }<br /> }<br /> }<br />

    真实 profile 输出(Easysearch 2.2.0 / Lucene 9.12.2 实测)


    <br /> ############ track_total_hits = false(WAND 生效)############<br /> BooleanQuery (body:rare body:common body:extra)~2 breakdown:<br /> next_doc_count 101<br /> match_count 100<br /> score_count 100 ← 只给 100 篇高分候选打了分<br /> set_min_competitive_score_count 1 ← 反馈闭环生效!<br /> <br /> body:common (TermQuery)<br /> advance_count 100 ← common 链只推进到高分文档区<br /> shallow_advance_count 3 ← Block-Max 块级定位<br /> compute_max_score_count 3 ← Block-Max 分段上界计算<br /> body:extra (TermQuery)<br /> advance_count 1 ← extra 子句几乎整链跳过!<br /> <br /> ############ track_total_hits = true(WAND 剪枝关闭)############<br /> BooleanQuery breakdown:<br /> next_doc_count 20101<br /> match_count 20100<br /> score_count 20100 ← 给全部 20100 篇命中文档都打了分<br /> set_min_competitive_score_count 0 ← COMPLETE 模式,从不回传阈值<br /> <br /> body:common advance_count 20100<br /> body:extra advance_count 20001<br /> (shallow_advance_count / compute_max_score_count 均为 0)<br />

    两组的 score_count 相差悬殊——false 模式 100、true 模式 20100,差了 200 倍。WAND 把 20000 篇低分的 common extra 文档在打分前就跳过了,只对真正可能进 Top-10 的 100 篇高分候选调用了 score()。两组返回的 Top-10 完全相同(全是 h0–h9,score=5.2984)——剪枝只省功,不改结果。

    怎么读这份输出


    把两组数据并排看:

    | 字段 | track_total_hits: false(WAND 生效) | track_total_hits: true(WAND 关闭) | 解读 |
    | ---------------------------------------- | :------------------------------------: | :-----------------------------------: | ---------------------------------------------------------------------- |
    | score_count | 100 | 20100 | 文档级剪枝的直接证据:WAND 跳过 99.5% 的低分文档,只对高分候选打分 |
    | set_min_competitive_score_count | 1 | 0 | 反馈闭环:只有 false 模式下 Collector 才回传阈值 |
    | shallow_advance_count(common 子句) | 3 | 0 | Block-Max 铁证:只在 false 模式做块级 advanceShallow |
    | compute_max_score_count(common 子句) | 3 | 0 | Block-Max 铁证:只在 false 模式算分段上界 |
    | advance_count(extra 子句) | 1 | 20001 | 低分上界子句被 WAND 几乎整链跳过 |
    | next_doc_count | 101 | 20101 | false 模式连外层迭代都提前结束了 |
    | hits.total | 省略 | {"value":20100,"relation":"eq"} | false 模式不精确计数、允许早退 |

    三条证据互相印证:

  • set_min_competitive_score_count 从 0 变 1:Collector 在收集到第 K 个高分结果后,把当前最低分回传给 WANDScorer(Scorer.setMinCompetitiveScore),这是 §一"剪枝反馈闭环"在 profile 里的落地。track_total_hits: true 时 TopScoreDocCollector 的 hitsThresholdChecker 是 no-op(isThresholdReached() 恒为 false),永不回传,所以为 0。
  • score_count 从 20100 暴跌到 100:20000 篇 common extra 文档的上界(≈0.01)够不到及格线(≈5.30),在 score() 之前就被跳过——这正是 WAND"用廉价的上界比较换掉昂贵的打分"的直接体现。
  • shallow_advance_count / compute_max_score_count 只在 false 模式非零:这正是 §一所讲 Block-Max 的 advanceShallow() / getMaxScore() 在底层被调用的痕迹。注意它们挂在 TermQuery 叶子节点上、而不是 BooleanQuery 顶层节点——后者这两个字段恒为 0。

    ⚠️ 另需注意:score_count 反映的是进入 collect() 的打分次数,只有在"高分文档先出现、低分文档上界够不到及格线"的分布下才会明显下降——若所有命中文档分数接近(例如每篇内容雷同),及格线抬不上去,score_count 就不会降。

    对比实验:观察耗时差异


    将 track_total_hits 改为 true,同样查询再次执行,对比 score 耗时(纳秒,单次运行波动较大,下为多次中位数):

    <br /> track_total_hits=false: score ≈ 37,000 ns score_count = 100<br /> track_total_hits=true: score ≈ 2,640,000 ns score_count = 20100 (约 70 倍)<br />

    本例剪枝比例高达 99.5%,false 模式的 score 耗时约只有 true 模式的 1/70——因为它只对 1% 的文档真正打分。数据集越大、剪枝比例越高,WAND 的收益越显著。(profile 本身有额外开销、绝对数字仅供参考,关键看两种模式的相对差距。)

    小结


    判断 Block-Max WAND 是否生效,看 set_min_competitive_score_count 是否从 0 变非零(机制是否触发);评估收益大小,看 score_count 的下降幅度与 score 耗时的相对差距。

    ---

    四、开发者启示:写查询时该注意什么?


    既然子句顺序在 Easysearch 2.x 上无需操心,那开发者应该关注什么?

    ✅ 用 filter 代替 must 当不需要评分时


    filter 不参与评分,走 ConstantScoreQuery 路径更高效。同时,filter 子句不会增加 WANDScorer 的调度开销——它们被提升到 MUST 后走合取路径,与评分子句的 WAND 调度互不干扰。

    ✅ 让 Lucene 做 minShouldMatch 优化


    当 should 数量恰好等于 minimumShouldMatch 时,所有 SHOULD 子句自动提升为 MUST([第一篇](https://infinilabs.cn/blog/202 ... art-1/)规则 10),走 ConjunctionScorer 而非 WANDScorer。这意味着:你不需要手动把 should 改成 must 来"帮助"优化器——只要语义上等价,Lucene 会自动做正确的选择。

    ✅ 避免嵌套过深的布尔查询


    展平 SHOULD 嵌套能让 WAND 看到更多子句([第一篇](https://infinilabs.cn/blog/202 ... art-1/)规则 9)。嵌套结构下,内层 BooleanQuery 的子句对外层 WANDScorer 不可见,maxScore 上界估计不够紧,剪枝效果打折。手动展平或让 rewrite 自动展平,都能提升 WAND 效率。

    ✅ 理解 cost 的含义


    cost ≈ 匹配文档数的估算,不是执行时间。一个高 cost 的 TermQuery 可能有极佳的缓存局部性(因为倒排列表是连续存储的),而一个低 cost 的 PointRangeQuery 可能需要更昂贵的验证逻辑。Profile API 的 advance 次数分布才是实际性能的真实反映。

    ❌ 不必刻意调整子句顺序(Easysearch 2.x)


    底层会自动按 cost(合取)或 maxScore(析取)排序。无论是先写高频词还是低频词,执行时的迭代顺序完全相同。唯一例外见[第二篇](https://infinilabs.cn/blog/202 ... art-2/) §八的边界说明:Easysearch 1.x(Lucene 8.11)上 must/filter 混有多词查询时,仍应把稀疏 term 写在前面;Easysearch 2.x(Lucene 9.12)已随 Lucene 9.5 的修复免疫此问题。

    🔧 Easysearch 的额外能力


    在 Lucene 标准优化栈之外,EasySearch 还提供一个面向特定场景的增强能力:

    fast_terms 插件:在高基数字段(如 user_id、tag_id 数万量级)上,用 RoaringBitmap 位图集合替代 N 个独立 TermQuery,将 N 次倒排索引查找压缩为 1 次 bitmap 交集。查询 DSL 名为 fast_terms,但其在 Profile 中对应的 type 是底层 Query 类的简单名 FilterQuery(type 取自 query.getClass().getSimpleName(),而插件实现类是 org.infinilabs.query.FilterQuery)。底层 FilterQuery 走 ConstantScoreWeight + 位图迭代器,advance 调用次数从 O(N·命中数) 降到 O(命中数)。

    它走自己的 Scorer 体系、不参与 BooleanQuery 的 rewrite / cost 排序 / WAND 剪枝路径,可按需叠加。

    ---

    五、全系列总结


    四篇文章,从构建层到执行层,从合取到析取,我们完整走过了 EasySearch 布尔查询的优化全景:

    1. 构建层([第一篇](https://infinilabs.cn/blog/202 ... art-1/)):11 条 rewrite 规则

      Easysearch + Lucene 的构建层不做代价排序,但通过 11 条代数规则自动简化查询结构。其中对执行性能影响最大的两条——规则 7(SHOULD+FILTER→MUST 提升)和规则 9(SHOULD 嵌套展平)——改变了子句的执行路径,为后续优化铺路。Profile API 可以直接观察 rewrite 后的查询形态(+ 前缀 = MUST 提升成功)。

    2. 执行层·合取([第二篇](https://infinilabs.cn/blog/202 ... art-2/)):cost 排序 + leadCost 传播

      ConjunctionDISI 按 cost 排序,让最稀疏的迭代器领头——它是跳过无效文档速度最快的那个。两阶段验证按 matchCost 排序,leadCost 实现从外向内的策略传播(如 IndexOrDocValuesQuery 的自适应切换)。Profile 的 advance 次数分布是最直接的佐证:lead1 的 advance 次数远大于 followers。

    3. 执行层·析取 I([第三篇](https://infinilabs.cn/blog/202 ... art-3/)):WAND 动态调度

      WANDScorer 按 maxScore 动态调度子句,高分优先推进——这是 Top-K 查询能早退的根本保障。三堆(head/lead/tail)分工合作,用大量廉价的上界比较换掉少量昂贵的精确打分。

    4. 执行层·析取 II(本文):Block-Max 块级剪枝

      Block-Max WAND 在块级别进一步剪枝:用分段级 maxScore 替换全局 maxScore,整块低分文档一次跳过。shallow_advance 字段可验证其效果。剪枝的反馈闭环——Collector → WANDScorer → ImpactsDISI——实现自适应加速:越到后面,剪枝越激进。

      给开发者的核心建议


    5. 无需关心子句书写顺序——底层自动优化。Easysearch 2.x(Lucene 9.12)确实如此;1.x 基于 Lucene 8.11,must/filter 混有 prefix/wildcard 等多词查询时,仍建议把稀疏的 term 子句写在前面(见[第二篇](https://infinilabs.cn/blog/202 ... art-2/) §八的边界说明)
    6. 关注查询结构设计——用 filter 替代不需要评分的 must,展平嵌套 SHOULD
    7. 善用 Profile API——set_min_competitive_score_count(反馈闭环是否生效)、advance/shallow_advance 次数(跳过力度)、score 耗时(打分成本)是核心观测指标;注意不带 _count 后缀的是纳秒耗时,带 _count 的才是次数
    8. 理解 WAND 的触发条件——track_total_hits: false 切入 TOP_SCORES 开启剪枝(纯析取走 MaxScoreBulkScorer,minimum_should_match > 1 才走 WANDScorer),track_total_hits: true 走 COMPLETE 关闭剪枝;对比 set_min_competitive_score_count 是否从 0 变非零是判断剪枝是否生效最直接的方式,score_count 的下降幅度则量化了实际收益

      作者:冯田立,极限科技(INFINI Labs)Easysearch 搜索引擎研发专家,曾在亚马逊 AWS 有多年的 Elasticsearch 开源插件和 OpenSearch 的开发经验和客户集群的运维经验,并有幸参与 OpenSearch 的创立。

Easysearch 布尔查询子句重排序(三)|WAND 动态剪枝

Easysearch • INFINI Labs 小助手 发表了文章 • 0 个评论 • 9282 次浏览 • 2026-09-29 19:05 • 来自相关话题

![](https://infinilabs.cn/img/blog ... er.png)

Lucene 如何用 WAND 算法实现 Top-K 的动态剪枝

_[INFINI Easysearch](https://easysearch.cn/) 是一款专注于企业级场景的分布式近实时搜索与分析引擎。它以 Apache Lucene 为内核进行了增强,在保持与行业主流搜索协议及开发生态完全兼容的同时,重点强化了安全性、稳定性、压缩率及信创兼容性。_

---

一、回顾与引入


在[第二篇](https://infinilabs.cn/blog/202 ... art-2/)中,我们深入解析了合取查询的核心优化:ConjunctionDISI 按 cost 排序,让最稀疏的迭代器领跑。这套机制的前提是 AND 语义——所有子句都必须匹配,所以"谁最少"就决定了跳转的速度。

本文聚焦析取(SHOULD)场景。关键转变在于:不需要全部匹配,而是找 Top-K 高分文档。 优化目标从"最少匹配"变为"最高分数贡献"——cost 最低的子句不一定分数最高,而分数最高的子句才最可能帮你快速找到 Top-K。

这就是 WAND 算法的用武之地。

---

二、为什么析取不能简单按 cost 排序?


合取和析取的本质差异决定了它们的优化策略必须不同:

| 维度 | 合取(MUST) | 析取(SHOULD) |
| -------- | -------------------- | ------------------------------ |
| 语义 | 所有子句都匹配 | 至少部分子句匹配 |
| 优化目标 | 快速排除不匹配文档 | 快速找到 Top-K 高分文档 |
| 排序依据 | cost(谁最少谁领头) | maxScore(谁分最高谁优先推进) |
| 关键操作 | 跳过不匹配的文档 | 跳过不可能进 Top-K 的文档 |

为什么 cost 排序在析取中不适用?看一个析取查询 should: [quantum, the, machine_learning],找 Top-10:

| 子句 | 匹配文档数(cost) | 单次命中得分 |
| ------------------ | ------------------ | ------------ |
| quantum | 100 | 8.0 |
| the | 1000 万 | 0.1 |
| machine_learning | 5 万 | 15.0 |

设想文档 D:不含 "quantum",但同时命中 "the" 和 "machine_learning",总分 = 0.1 + 15.0 = 15.1,远超只命中 "quantum" 的文档(8.0),理应排进 Top-10。

若按 cost 让 quantum 领头驱动,候选集就由 quantum 的倒排表决定——D 不在其中,根本不会被推进打分,Top-K 就漏掉了 15.1 分。根源在于:cost 衡量的是"匹配多少",分数衡量的是"匹配多高",两者是不同的维度。

析取需要的是感知分数的调度策略:优先推进高分子句,快速积累 Top-K,再用已有最低分剪枝。这就是 WAND(Weak AND)算法的核心思想。

WANDScorer 的触发条件


WANDScorer 不是总生效。Lucene 在 Boolean2ScorerSupplier.opt() 中按以下条件决定是否为 SHOULD 子句构造 WANDScorer(否则走 DisjunctionSumScorer):

java<br /> if ((scoreMode == ScoreMode.TOP_SCORES && topLevelScoringClause) || minShouldMatch > 1) {<br /> return new WANDScorer(weight, optionalScorers, minShouldMatch, scoreMode);<br /> } else {<br /> return new DisjunctionSumScorer(weight, optionalScorers, scoreMode);<br /> }<br />

即满足其一即可:

  1. ScoreMode.TOP_SCORES 且该析取是顶层评分子句:正常的 _search 请求默认 track_total_hits=10000,对应 TOP_SCORES(由 TopScoreDocCollector 设置)
  2. minShouldMatch > 1:此时即便非 TOP_SCORES 也用 WANDScorer 做合取推进

    注意第 1 个条件里的 TOP_SCORES 是 Lucene 的内部评分模式,由请求参数 track_total_hits 间接决定用哪个 Collector,Collector 再把自己的 ScoreMode 报给 Scorer:

    • track_total_hits: false(默认 10000 亦同)→ TOP_SCORES:收集满 Top-K 后允许提前结束,并把当前最低分回传给 WANDScorer,剪枝开启
    • track_total_hits: true → COMPLETE:必须遍历全部匹配文档以给出精确总数,从不回传阈值,剪枝关闭

      所以同一条 SHOULD 查询(默认 minShouldMatch=1),只改 track_total_hits 就能让 WANDScorer 在"真正剪枝"与"改走 DisjunctionSumScorer 不剪枝"之间切换——这正是[下一篇 §三](https://infinilabs.cn/blog/202 ... 4/%23三动手验证profile-api-观察-wand-生效)对比实验的切入点。

      ---

      三、WAND 的核心逻辑:用上界剪枝


      3.1 WAND 要解决什么:Top-K 与一条不断抬高的及格线


      析取查询的本质:SHOULD 子句"至少匹配一个",但用户要的不是全部匹配文档,而是分数最高的 K 个(Top-K)。

      既然只要 Top-K,就有一条隐形的及格线——当前已收集结果中第 K 名的分数,记作 minCompetitiveScore(最小竞争分)。超过它才可能挤进 Top-K,超不过一定落选。随着高分文档不断被收进来,这条线只会越抬越高。

      于是每个候选文档只须回答一个问题:分数能不能超过及格线? 能才值得精确打分,不能就该跳过,不进行精确打分。WAND 的全部巧思都围绕上述这点,而办法就是下节介绍的"分数上界"。

      3.2 从查询语句到游标:每个 SHOULD 子句是一条独立的倒排链


      先看一条布尔查询长什么样:

      json<br /> {<br /> "bool": {<br /> "should": [<br /> { "term": { "body": "rare" } },<br /> { "term": { "body": "common" } },<br /> { "term": { "tag": "hot" } }<br /> ]<br /> }<br /> }<br />

      里的每个 SHOULD 子句,底层都是一次独立的倒排索引查找,对应一条倒排链(posting list)——按 docID 升序排列的、所有命中该词的文档号序列。每条倒排链配一个游标 docID,指向"这条链上下一个待处理的匹配文档"。

      3 个 SHOULD 子句 = 3 条倒排链 = 3 个游标。WANDScorer 就是同时管着这几个游标、协调它们推进的调度器。

      下面给这三条倒排链画个框图(沿用[下一篇 §三](https://infinilabs.cn/blog/202 ... 4/%23三动手验证profile-api-观察-wand-生效)对比实验里 body:rare / body:common / tag:hot 的语义:一个稀有词、一个常见词、一个中等标签),方便后续讲游标怎么移动。竖线是已经按 docID 升序排好的匹配文档号,▼ 就是游标当前指向的那个文档:

      <br /> SHOULD 子句 倒排链(命中的 docID,升序排列) 游标 ▼<br /> <br /> ┌──────────────┐<br /> │ rare (稀有) │ ··· 42 ──── 87 ──── 156 ──── 203 ──── 318 ──── ···<br /> └──────────────┘ ▲<br /> │ 当前指向 doc=42<br /> <br /> ┌──────────────┐<br /> │ common (常见)│ ··· 5 ── 42 ── 87 ── 88 ── 156 ── 203 ── 318 ── 410 ── ··· (很长,省略)<br /> └──────────────┘ ▲<br /> │ 当前指向 doc=5<br /> <br /> ┌──────────────┐<br /> │ hot (标签) │ ··· 17 ──── 87 ──── 203 ──── 318 ──── ···<br /> └──────────────┘ ▲<br /> │ 当前指向 doc=17<br /> <br /> ───────────────────────────────────────────────────────────────────→ docID 轴<br /> 5 17 42 87 88 156 203 318 410<br />

      看这张图,三条游标彼此独立、各自往前走。WANDScorer 的全部工作,就是在它们之间做调度:选一个"候选文档号"(比如最小的 5),把三条游标推进到那个号去查命中,命中就打分、不命中就跳过。三堆(§3.4)就是为这个调度做的分工。

      关键认知:一个文档的最终得分 = 它实际命中的那些子句的实际得分之和。子句"命中没命中",看那条倒排链里有没有这个文档号——这正是游标要查的事。

      3.3 核心招式:用分数上界代替实际分数


      最直白的判断法:把命中的子句逐个 score() 加起来跟及格线比。但 score() 很贵(要算 BM25 的 tf/idf),WAND 不想这么干。

      WAND 的招式:别算实际分数,估一个上界就够了。 每个子句在构建时就算好 maxScore——"命中任何文档最多贡献多少分"(由 idf 和该词可能的最高 tf 决定,计算成本低)。于是:

      文档分数上界 = 所有"可能命中"的子句的 maxScore 之和

      这个上界必然 ≥ 实际分数:maxScore ≥ 实际得分(往大了估),"可能命中" ⊇ 真正命中(多算不漏算)。

      判断就简单了:上界 < 及格线 → 直接跳过,一次 score() 都不调用。 只有上界 ≥ 及格线的文档才值得精确打分。WAND 的全部收益就来自这里——用大量廉价的上界比较换掉少量昂贵的精确打分("Weak"也在这里:不强求每个子句都查实,上界够用就敢下结论)。

      那么"可能命中"怎么界定?答案在游标位置里。

      3.4 游标的三种位置 → 三堆


      §3.3 留了一个问题:哪些子句算"可能命中"?答案全在游标上。

      WANDScorer 手里攥着好几条倒排链的游标,各自独立推进。每一轮它选定一个候选文档号(从哪来的 §3.5 会说,眼下先当成参照点),然后看每条链的游标相对这个文档号,只可能有三种位置:

    • 游标 == 候选号:确认命中 → 上界按 maxScore 计
    • 游标 > 候选号:游标走过头了,确认不命中 → 贡献 0
    • 游标 < 候选号:还没走到,未知 → 上界按 maxScore 计(往乐观了估)

      WANDScorer 把这三类子句分别放进三个结构,叫三堆:确认命中的进 lead,确认不命中的进 head,未知的进 tail。

      <br /> ┌────────────────────────────────────────────────────────────────┐<br /> │ WANDScorer 内部结构 │<br /> │ │<br /> │ tail (maxScore 最大堆) lead head (按 docID 升序) │<br /> │ ┌──────────────┐ ┌─────┐ ┌──────────────┐ │<br /> │ │ S4 maxScore=9│ │ S1 │ │ S3 doc=156 │ │<br /> │ │ S2 maxScore=5│ │ S5 │ │ S6 doc=203 │ │<br /> │ │ S7 maxScore=2│ │ │ │ │ │<br /> │ └──────────────┘ └─────┘ └──────────────┘ │<br /> │ │<br /> │ 游标还没到当前文档 游标已停在 游标已越过当前文档 │<br /> │ 按 maxScore 排, 当前文档 按 docID 排, │<br /> │ 按需推进 决定下个候选 │<br /> └────────────────────────────────────────────────────────────────┘<br />

      三个结构各有分工:

    • tail 按 maxScore 排最大堆,堆顶是分数最高的子句。上界不够时优先借它——最可能一把把上限抬过线。
    • lead 是简单链表,串起确认命中的子句。精筛过关后逐个 score() 算实际分。
    • head 按 docID 排最小堆,堆顶是文档号最小的——它就是下一个候选文档。

      子句在三堆间流转:tail → lead → head。未知的推进去查,命中进 lead,不命中进 head。下一轮候选变了,部分子句又可能从 head 回到 tail。

      3.5 搜索流程:粗筛、精筛与阈值回传


      把三堆放回完整流程。WANDScorer 对外是个 Scorer,被 Collector 驱动,主循环四步:

      <br /> Collector 主循环:<br /> while ((doc = scorer.nextDoc()) != NO_MORE_DOCS) { // ① 粗筛<br /> if (twoPhase.matches()) { // ② 精筛<br /> float s = scorer.score(); // ③ 精确打分<br /> collector.collect(doc, s); // ④ 收入结果 + 抬及格线<br /> }<br /> }<br />

    • ① 粗筛:候选从 head 堆顶来(docID 最小者)。nextDoc() 调用 doNextCompetitiveCandidate([WANDScorer.java:492-504](https://github.com/apache/luce ... 2-L504))做一道文档级跳过:若 leadMaxScore + tailMaxScore < 及格线,说明当前 doc 凑不够分,直接推进到下一个候选文档(可能跨 block)。这里只用每子句一个的 maxScore 求和判断,不逐子句查实命中、不算精确分。
    • ② 精筛:留在候选 doc 上,逐个借 tail 堆顶推进查实命中,看实际能否过线。这是 WAND 的核心动作,§3.6 专门展开。
    • ③ 精确打分:通过精筛的,把 lead 里确认命中的子句逐个 score() 加总。
    • ④ 阈值回传:收满 K 个后,Collector 把第 K 名分数回传给 WANDScorer 抬高及格线——下一篇 §一展开。

      3.6 核心剪枝:上界估计与"借 tail"


      对当前候选文档,WAND 只回答一个问题:它的分数上界够不够得着及格线?

      上界 = lead 各子句 maxScore 之和(确认命中,贡献确定)+ tail 各子句 maxScore 之和(未知,按 maxScore 乐观估算)。这个上界必然 ≥ 实际分数。判断逻辑([WANDScorer.java:309-332](https://github.com/apache/luce ... 9-L332)):

      java<br /> while (leadMaxScore < minCompetitiveScore) { // lead 自己不够线<br /> if (leadMaxScore + tailMaxScore < minCompetitiveScore)<br /> return false; // 加上 tail 的乐观估计仍不够 → 必败,跳过<br /> advanceTail(); // 还有希望:从 tail 堆顶借一个子句去"查实"<br /> }<br /> return true; // lead 已够线 → 留下精确打分<br />

      关键在 advanceTail()——把 tail 堆顶(maxScore 最高的未知子句)推进到当前文档查实,结果只有两种:

    • 命中 → 进 lead,leadMaxScore 实打实涨上去;
    • 不命中 → 进 head,从 tail 挪走,tailMaxScore 掉下来。

      每借一次,要么抬高下限(leadMaxScore),要么压低上限(tailMaxScore)。文档能不能活下来,取决于借出的子句里有多少真的命中;借遍 tail 仍够不到线就判负,全程没调用过 score()。

      为什么永远先借堆顶? tail 按 maxScore 排最大堆,堆顶最可能一下把 leadMaxScore 抬过线;如果连 leadMaxScore + tailMaxScore 都够不到线,低分子句就无需查实,候选文档可以直接跳过。这就是 WAND 的 "Weak":不强求每个子句都查实,只查可能扭转局面的那几个。

      3.7 数字示例:一次完整的剪枝流程


      先给出示例的场景。3 个 SHOULD 子句,各自的 maxScore 和倒排链(命中的 docID,已升序排列)如下,及格线 minCompetitiveScore = 10:

      | 子句 | maxScore | 倒排链(命中的 docID) |
      | ---- | -------- | ---------------------- |
      | S1 | 8 | 42, 55 |
      | S2 | 5 | 20, 55 |
      | S3 | 3 | 30, 60 |

      每个子句配一个游标,指向"这条链下一个待处理的 docID"。假设已经收集满 Top-K、及格线抬到了 10,本轮候选文档 = 42(S1 的游标停在 42,刚从 head 堆顶取出作为领头);S2、S3 因 maxScore 较低此前没被推进,游标还分别停在 20、30。把三个子句按"游标 vs 候选 42"的位置归堆:

    • S1 游标 42 == 42:确认命中 doc=42 → lead
    • S2 游标 20 < 42:还没走到,未知 → tail
    • S3 游标 30 < 42:还没走到,未知 → tail
    • 没有游标 > 42 的子句 → head 此刻是空的(S1 已进 lead,S2、S3 还在 tail)

      于是初始状态:lead 装着 S1(确认能拿 8 分),tail 装着 S2、S3(最多还能补 5+3=8 分),head 空。

      下面这张状态图把 doc=42 接下来怎么被剪掉完整画了出来。关键看右侧三个数:leadMax = lead 里已确认能拿的分数,tailMax = tail 里最多还能补多少(乐观估计),二者之和就是分数上界。每借一次 tail 堆顶,三堆成员就流动一次,这三个数也跟着变:

      <br /> 候选 doc=42, 及格线 = 10 分数上界 = leadMax + tailMax<br /> <br /> head lead tail leadMax tailMax 上界 动作<br /> ┌────────────┐ ┌─────────┐ ┌──────────┐<br /> 初始 │ (空) │ │ S1: 8 │ │ S2: 5 │ 8 8 16 ≥10<br /> │ │ │ │ │ S3: 3 │ ─→ 借堆顶 S2<br /> ├────────────┤ ├─────────┤ ├──────────┤<br /> 借 S2 │ S2→55 │ │ S1: 8 │ │ S3: 3 │ 8 3 11 ≥10<br /> │ │ │ │ │ │ ─→ 借堆顶 S3<br /> ├────────────┤ ├─────────┤ ├──────────┤<br /> 借 S3 │ S2→55 │ │ S1: 8 │ │ (空) │ 8 0 8 <10<br /> │ S3→60 │ │ │ │ │ ─→ ✂️ 必败<br /> └────────────┘ └─────────┘ └──────────┘<br /> <br /> 表头解读:head/lead/tail 三栏列出各自的子句成员(含 maxScore);<br /> 右侧三个数描述【整堆】在这一轮的整体状态,不与某一格对应。<br />

      每一行发生了什么(看左栏 → 右栏):

    • 初始:lead 只有 S1 → leadMax=8 不够线(8 < 10),但加 tail 的乐观估计 tailMax=8 后上界 16 ≥ 10,还有希望 → 借 tail 堆顶 S2 查实。
    • 借 S2:把 S2 游标从 20 推进到 ≥42,结果落在 55(> 42,没命中)→ S2 进 head,tailMax 从 8 掉到 3。leadMax 还是 8(没涨,因为 S2 没命中),上界 11 ≥ 10 仍有希望 → 再借 S3。
    • 借 S3:把 S3 游标从 30 推进到 ≥42,落在 60(也没命中)→ S3 进 head,tailMax 从 3 掉到 0。上界 8 < 10 → 必败,剪掉 ✂️

      读懂这张图就懂了 WAND:借出的子句都没真命中 42,所以 leadMax 三轮卡在 8 不动;而每借走一个,tailMax 就往下掉一截(8→3→0)。上界随之从 16 一路缩到 8,最后跌穿及格线——doc=42 全程没调一次 score() 就被剪掉。

      再看下一个候选 doc=55——lead 自身就够线,根本不用借。doc=42 被剪掉后,S1 推进到它的下一个文档 55;S2、S3 在前面借出时已推进到 55、60。按游标位置归堆:S1、S2 游标 == 55 → lead;S3 游标 60 > 55 → head(贡献 0)。状态图只有一行:

      <br /> 候选 doc=55, 及格线 = 10<br /> <br /> head lead tail leadMax tailMax 上界 动作<br /> ┌────────────┐ ┌─────────┐ ┌──────────┐<br /> 初始 │ S3→60 │ │ S1: 8 │ │ (空) │ 13 0 13 ≥10 ─→ score()<br /> │ │ │ S2: 5 │ │ │ 实际 7+4=11 ✅<br /> └────────────┘ └─────────┘ └──────────┘<br />

      leadMax=13 一上来就够线,直接调 score() 算实际分 7+4=11 > 10 → ✅ 收入 Top-K。两图对比,分水岭就在第一行:doc=42 的 lead 只值 8,被迫借 tail 撞运气;doc=55 的 lead 自值 13,一步过关。同一个及格线下,命运全由"借出来的子句有没有真命中"决定——这正是 §3.6 那句"借堆顶"的实战含义。

      3.8 cost 作为平局打破者(tiebreaker)


      当两个子句 maxScore 相同时,推进 cost 更低(更稀疏)的子句更高效——它每次 advance 跳过的文档更多。

      代码解读:tail 堆的比较器 greaterMaxScore()([WANDScorer.java:643-651](https://github.com/apache/luce ... 3-L651))以 scaledMaxScore 为主键排序,maxScore 相同时用 cost 做次键:

      java<br /> private static boolean greaterMaxScore(DisiWrapper w1, DisiWrapper w2) {<br /> if (w1.scaledMaxScore > w2.scaledMaxScore) return true;<br /> else if (w1.scaledMaxScore < w2.scaledMaxScore) return false;<br /> else return w1.cost < w2.cost; // 分数并列时,cost 更低(更稀疏)的排前面<br /> }<br />

      ---

      小结


      本文把 WAND 讲透了:一条不断抬高的及格线 + 用 maxScore 估上界 + 三堆分工调度游标 + 完整数字例子。核心就一句——用大量廉价的上界比较,换掉少量昂贵的精确打分。

      但标准 WAND 还有一个明显短板:每个子句的上界是全局的,游标走到低分区域仍按全局最高分估,剪枝不够激进。Block-Max WAND 怎么补上这块、混合查询怎么端到端跑起来、Profile API 怎么亲手验证剪枝生效——都在[下一篇(四)](https://infinilabs.cn/blog/202 ... art-4/)。

      作者:冯田立,极限科技(INFINI Labs)Easysearch 搜索引擎研发专家,曾在亚马逊 AWS 有多年的 Elasticsearch 开源插件和 OpenSearch 的开发经验和客户集群的运维经验,并有幸参与 OpenSearch 的创立。